从入门到精通:如何解决C++模板代码膨胀问题?
作者:guoling,来自微信客户端团队
前言
背景:C++ 模板是一种强大的编程工具,它允许我们编写通用的、可重用的代码; 问题:模板代码的一个常见问题是代码膨胀,即编译器为每个模板实例生成大量的重复代码。现代的编译器已经能够对不同编译单元里的相同模板函数进行去重,老生常谈的 external 模板、将模板代码与非模板代码分离等,对瘦身意义已经不大,我们仍然需要关注如何减少每一个模板实例化的大小。
除了显而易见的减少实例化类型的数量(实际业务场景下其实大部分减不了),「本文主要是提供适用于一些具体场景、可实际操作的优化策略以减少C++模板代码的大小。」
策略说明
主要包括:
模板函数:提取通用部分 模板类:抽象出通用部分到基类 合理使用模板 小技巧:多用组合、避免使用大型对象等等。
1. 将模板函数的通用部分提取出来
如果模板函数中有一部分代码与模板参数无关,那么可以将这部分代码提取出来,放到一个非模板函数中。这样,这部分代码只需要生成一次,而不是在每个模板实例中都生成一次。
为了方便讨论,后续的例子基于这个场景:我们提供一个集中的 Service 单例管理器。以下是大体的框架:
// 所有 Service 需要实现的接口
class BaseService {
public:
virtual ~BaseService() = default;
virtual void onServiceInit() = 0;
……
std::string contextName{};
};
// 所有 Service 单例的统一管理中心
class ServiceCenter {
public:
explicit ServiceCenter(const std::string& name) : _contextName(name) {}
template<typename T>
std::shared_ptr<T> getService() {
……
}
private:
std::unordered_map<std::string, std::shared_ptr<BaseService>> _serviceMap = {};
std::string _contextName;
std::recursive_mutex _mutex;
};
1.1 最简单的情形,函数大部分逻辑都是跟模板参数无关:
例如,在我们的例子中,getService() 函数最简单的版本可能长这样,显然,一大部分代码是与模板参数无关的,可以提取出来:
class ServiceCenter {
public:
template<typename T>
std::shared_ptr<T> getService() {
auto const key = typeid(T).name();
std::lock_guard<std::recursive_mutex> lock(_mutex);
auto const itr = _serviceMap.find(key);
if (itr == _serviceMap.end()) {
return nullptr;
}
auto service = itr->second;
return std::dynamic_pointer_cast<T>(service);
}
};
我们抽出一个非模板的函数getService(const std::string &key),将加锁、查询 map 这些逻辑都挪进去,优化后:
class ServiceCenter {
public:
std::shared_ptr<BaseService> getService(const std::string &key) {
std::lock_guard<std::recursive_mutex> lock(_mutex);
auto const itr = _serviceMap.find(key);
if (itr == _serviceMap.end()) {
return nullptr;
}
return itr->second;
}
template<typename T>
std::shared_ptr<T> getService() {
auto const key = typeid(T).name();
auto service = getService(key);
return std::dynamic_pointer_cast<T>(service);
}
};
1.2 稍复杂的情形,函数大部分逻辑都跟模板参数有关:
例如,getService()函数不但要管查询,还要按需创建新实例、初始化、以及各种异常处理,有3行代码都用了类型T:
class ServiceCenter {
public:
template<typename T>
std::shared_ptr<T> getService() {
std::lock_guard<std::recursive_mutex> lock(_mutex);
auto const key = typeid(T).name();
auto const service = getService(key);
if (service == nullptr) {
auto const tService = std::make_shared<T>();
tService->contextName = _contextName;
setService(key, tService);
tService->onServiceInit();
return tService;
} else {
auto const tService = std::dynamic_pointer_cast<T>(service);
if (tService == nullptr) {
aerror("ServiceCenter", "tService is null");
return nullptr;
}
return tService;
}
}
void setService(const std::string &key, const std::shared_ptr<AffBaseService> &service) {....}
};
这种情况我们可以将需要用到类型T的地方进行封装,抽象出一套协议。具体来说,用到T的地方分别有:
typeid(T).name(): 可抽象出接口getTypeName(),通过 RTTI 返回类型的名字。 std::make_shared(): 考虑到对tService接下来的操作都是已在基类BaseService里定义的接口,可抽象出接口newInstance(),返回基类指针。 std::dynamic_pointer_cast(): 这里主要是将基类指针动态地转换为子类指针,可抽象出接口castToOriginType(),在里面进行类型转换,返回一个void类型的指针。
使用最常见的多态(也可用 C 函数指针数组std::function<>传参等)来组织这 3 个抽象接口:
class ServiceTypeHelperBase {
public:
virtual ~ServiceTypeHelperBase() = default;
virtual const char* getTypeName() const = 0;
virtual std::shared_ptr<BaseService> newInstance() const = 0;
virtual std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const = 0;
};
template<typename T>
class ServiceTypeHelper : public ServiceTypeHelperBase {
const char* getTypeName() const override {
return typeid(T).name();
}
std::shared_ptr<BaseService> newInstance() const override {
return std::make_shared<T>();
}
std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const override {
return std::dynamic_pointer_cast<T>(service);
}
};
然后就可以抽出非模板的函数getService(const ServiceTypeHelperBase* helper)。可见到,得益于合理的抽象,新函数跟没优化之前的getService()几乎一模一样:
class ServiceCenter {
public:
std::shared_ptr<void> getService(const ServiceTypeHelperBase* helper) {
std::lock_guard<std::recursive_mutex> lock(_mutex);
auto const key = helper->getTypeName();
auto const service = getService(key);
if (service == nullptr) {
auto const tService = helper->newInstance();
tService->contextName = _contextName;
setService(key, tService);
tService->onServiceInit();
return helper->castToOriginType(tService);
} else {
auto const tService = helper->castToOriginType(service);
if (tService == nullptr) {
aerror("ServiceCenter", "tService is null");
return nullptr;
}
return tService;
}
}
template<typename T>
std::shared_ptr<T> getService() {
ServiceTypeHelper<T> helper;
auto service = getService(&helper);
return std::static_pointer_cast<T>(service);
}
};
注意,抽象出来的接口必须「足够精简,避免换个地方写模板函数」;太复杂的话,起不到瘦身效果。例如下面这样,就是不够精简的抽象:
// 反面例子,不要这样做
template<typename T>
class ServiceTypeHelper : public ServiceTypeHelperBase {
const char* getTypeName() const override {
return typeid(T).name();
}
std::shared_ptr<BaseService> newInstance() const override {
auto const tService = std::make_shared<T>();
tService->contextName = _contextName;
setService(key, tService);
tService->onServiceInit();
return tService;
}
std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const override {
auto const tService = std::dynamic_pointer_cast<T>(service);
if (tService == nullptr) {
aerror("ServiceCenter", "tService is null");
return nullptr;
}
return tService;
}
};
2. 将模板类的通用部分提取到基类
❝特别注意:这里的基类指「非模板基类」,或者「模板参数比子类少的基类」;否则只是换个地方写模板类,起不到瘦身效果。
❞
编译器每实例化一个模板类,会将类的所有部分都复制一份,包括非模板成员变量、模板成员变量、非模板函数、模板函数。尤其是「非模板成员变量和非模板函数,也会复制生成一份」,即使它们没有用到模板信息。这是很多人都会忽视的地方。因此,将通用部分提取到基类,避免编译器重复生成同样的代码,就成了瘦身的有效手段。
为了方便讨论,依然以 ServiceCenter 来举例。假设我们每个大业务(朋友圈、视频号等)都有自己的一套 XXXBaseService,定制了一些大业务相关的通用处理逻辑,因此他们希望有业务专门的 XXXServiceCenter,大概是这么一个架构:
// 业务相关的 BaseService 协议
class BussinessBaseService : public BaseService {
public:
BussinessBaseService(const std::string& name) : _bussinessName(name) {}
virtual void onBussinessEnter() = 0;
virtual void onBussinessExit() = 0;
……
protected:
const std::string _bussinessName;
};
// 业务相关的 ServiceCenter
template <typename BaseService_t>
class ServiceCenter {
public:
explicit ServiceCenter();
public:
void setService(const std::string &key, const std::shared_ptr<BaseService> &service);
std::shared_ptr<BaseService> getService(const std::string &key);
template<typename T>
std::shared_ptr<T> getService() {
static_assert(std::is_base_of<BaseService_t, T>::value, "Wrong Service Type");
……
}
……
private:
std::unordered_map<std::string, std::shared_ptr<BaseService>> _serviceMap = {};
std::string _bussinessName;
std::string _contextName;
std::recursive_mutex _mutex;
};
// 例如朋友圈业务 Service 基类
class TLBussinessServiceBase : public BussinessBaseService {
TLBussinessServiceBase(const std::string& name) : BussinessBaseService(name) {}
void onBussinessEnter() override { /*some common logic*/ }
void onBussinessExit() override { /*some common logic*/ }
……
};
// 朋友圈 ServiceCenter,要求所有 Service 都继承自 TLBussinessServiceBase
static ServiceCenter<TLBussinessServiceBase> g_tlServiceCenter;
2.1 将非模板成员变量和非模板函数提取到基类
这个是不言自明的机械操作:
class BaseServiceCenter {
public:
void setService(const std::string &key, const std::shared_ptr<BaseService> &service);
std::shared_ptr<BaseService> getService(const std::string &key);
protected:
std::unordered_map<std::string, std::shared_ptr<BaseService>> _serviceMap = {};
std::string _contextName;
std::recursive_mutex _mutex;
};
template <typename BaseService_t>
class ServiceCenter : BaseServiceCenter {
public:
template<typename T>
std::shared_ptr<T> getService() {
……
}
……
private:
std::string _bussinessName;
};
2.2 将模板函数抽出通用部分,挪到基类
我们先来看新版getService(),跟之前的有点不一样,Service 构造函数多一个参数:
template <typename BaseService_t>
class ServiceCenter : BaseServiceCenter {
public:
template<typename T>
std::shared_ptr<T> getService() {
static_assert(std::is_base_of<BaseService_t, T>::value, "Wrong Service Type");
std::lock_guard<std::recursive_mutex> lock(_mutex);
auto const name = typeid(T).name();
auto const service = getService(name);
if (service == nullptr) {
auto const tService = std::make_shared<T>(_bussinessName); // 这里不一样
tService->contextName = _contextName;
setService(name, tService);
tService->onServiceInit();
return tService;
} else {
auto const tService = std::dynamic_pointer_cast<T>(service);
if (tService == nullptr) {
aerror("ServiceCenter", "tService is null");
return nullptr;
}
return tService;
}
}
};
因此我们抽象出来的 XXXHelper::newInstance() 也需要多一个参数:
class BussinessServiceTypeHelperBase : public ServiceTypeHelperBase {
public:
std::shared_ptr<BaseService> newInstance() const override {
return nullptr;
}
virtual std::shared_ptr<BussinessBaseService> newInstance(const std::string& name) const = 0;
};
template<typename T>
class BussinessServiceTypeHelper : public BussinessServiceTypeHelperBase {
const char* getTypeName() const override {
return typeid(T).name();
}
std::shared_ptr<BussinessBaseService> newInstance(const std::string& name) const override {
return std::make_shared<T>(name);
}
std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const override {
return std::dynamic_pointer_cast<T>(service);
}
};
然后就可以类似 1.2 那样抽出共有逻辑,并挪到基类:
class BaseServiceCenter {
public:
std::shared_ptr<void> getService(const BussinessServiceTypeHelperBase* helper, const std::string& bussinessName) {
std::lock_guard<std::recursive_mutex> lock(_mutex);
auto const key = helper->getTypeName();
auto const service = getService(key);
if (service == nullptr) {
auto const tService = helper->newInstance(bussinessName);
tService->contextName = _contextName;
setService(key, tService);
tService->onServiceInit();
return helper->castToOriginType(tService);
} else {
auto const tService = helper->castToOriginType(service);
if (tService == nullptr) {
aerror("ServiceCenter", "tService is null");
return nullptr;
}
return tService;
}
}
};
template <typename BaseService_t>
class ServiceCenter : BaseServiceCenter {
public:
template<typename T>
std::shared_ptr<T> getService() {
static_assert(std::is_base_of<BaseService_t, T>::value, "Wrong Service Type");
BussinessServiceTypeHelper<T> helper;
auto service = getService(&helper, _bussinessName);
return std::static_pointer_cast<T>(service);
}
};
2.3 抽离(多模板参数)子类的共用部分,挪到(少模板参数的)基类
如果基类也有模板参数,那么应尽量使基类的模板参数比子类少,并把子类的共用部分挪到基类。例如,假设现在有如下子类和基类,T 的实例个数是 n,U 的实例个数是 m,那么子类的每个成员变量和成员函数都会「生成 n*m 份」;如果把子类里只与 T 相关的成员挪到基类,那么这些成员「只会生成 n 份」,少了一个数量级。更详细的分析可参考 Effective C++ 44:将参数无关代码重构到模板外去。
template<typename T, typename U>
class Pair {
public:
std::string getFirstTypeName() const {
auto typeName = typeid(T).name();
int status;
auto demangledName = abi::__cxa_demangle(typeName, 0, 0, &status);
if (!demangledName) {
return typeName;
}
std::string result = demangledName;
free(demangledName);
return result;
}
T first;
U second;
};
上面的例子里,getFirstTypeName()明显跟参数U无关,因此可抽出一个基类,并把该函数挪过去:
template<typename T>
class PairLeft {
public:
std::string getTypeName() const {
auto typeName = typeid(T).name();
int status;
auto demangledName = abi::__cxa_demangle(typeName, 0, 0, &status);
if (!demangledName) {
return typeName;
}
std::string result = demangledName;
free(demangledName);
return result;
}
};
template<typename T, typename U>
class Pair : private PairLeft<T> {
using Base = PairLeft<T>;
public:
std::string getFirstTypeName() const {
return Base::getTypeName();
}
};
❝还可以进一步将函数 PairLeft::getTypeName() 抽离出模板无关的部分,放到一个非模板基类里。具体可参考前文,不再赘述。
❞
3. 合理使用模板
不要为了用模板而用模板。这里举一个具体的反面例子(github上某开源库):一个提供了类似上述 ServiceCenter 功能的库,它侧重点是控制反转 Inversion of Control,亮点是对构造函数依赖的参数的自动查找和构建,以及类型映射。我们学习一个库,除了学它好的地方,也要看到它不好的地方,引以为鉴。
3.1 多余的模板参数
场景1:基类 RegistrationDescriptorBase<TDescriptor, TDescriptorInfo>有两个模板参数,仔细看代码,会发现压根没在基类用过。而这个会导致非常严重的代码膨胀,每个<TDescriptor, TDescriptorInfo>组合就会生成一套全新的基类。
场景2:工具类 EnforceBaseOf<TDescriptorInfo, TBase, T>里面的TDescriptorInfo参数在判断T是否继承自TBase时,完全没用,不知为何要加这么一个参数。
3.2 臃肿的模板组合
这个形容词我想了很久,没找一个合适的词去形容,因为实在太震撼。具体是这个类AutowireableConstructorRegistrationDescriptor:
template <class TDescriptorInfo>
class AutowireableConstructorRegistrationDescriptor : public RegistrationDescriptorBase< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
public RegistrationDescriptorOperations::As< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
public RegistrationDescriptorOperations::OnActivated< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
public RegistrationDescriptorOperations::SingleInstance< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
……
{
……
};
以及这个类RegistrationDescriptorInfo:
template
<
class T,
InstanceLifetimes::InstanceLifetime Lifetime = InstanceLifetimes::Transient,
class TSelfRegistrationTag = Tags::NotSelfRegistered,
class TFallbackRegistrationTag = Tags::DefaultRegistration,
class TRegisteredBases = MetaMap<>,
class TDependencies = MetaMap<>
>
struct RegistrationDescriptorInfo
{
typedef T InstanceType;
typedef std::integral_constant< InstanceLifetimes::InstanceLifetime, Lifetime > InstanceLifetime;
typedef TSelfRegistrationTag SelfRegistrationTag;
typedef TFallbackRegistrationTag FallbackRegistrationTag;
typedef TRegisteredBases RegisteredBases;
typedef TDependencies Dependencies;
struct SingleInstance
{
typedef RegistrationDescriptorInfo
<
InstanceType,
InstanceLifetimes::Persistent,
SelfRegistrationTag,
FallbackRegistrationTag,
RegisteredBases,
Dependencies
>
Type;
};
template <class TBase>
struct RegisterBase
{
typedef RegistrationDescriptorInfo
<
InstanceType,
InstanceLifetime::value,
SelfRegistrationTag,
FallbackRegistrationTag,
typename MetaInsert< RegisteredBases, MetaPair< TBase, MetaIdentity< TBase > > >::Type,
Dependencies
>
Type;
};
template <class TBase>
struct IsBaseRegistered : MetaContains< RegisteredBases, TBase >
{
};
……
};
这两个模板类组合起来,可以提供类似下面这样的语法:
auto contain_builder = Hypodermic::ContainerBuilder();
contain_builder.registerType<TServiceImp>()
.template as<TServiceInterface>()
.template as<TServiceBase>()
.singleInstance()
.onActivated(
[](Hypodermic::ComponentContext &ctx, const std::shared_ptr<TServiceImp> &service) {
// some onActive logic
}
);
看起来挺干爽的,有什么问题?
所有「调用信息和调用顺序」,都通过模板参数在RegistrationDescriptorInfo记录下来,这就意味着每多一步操作,就多了一个模板组合。就拿上面这个链式调用来说,一共有 5 个函数调用,也就生成了若干套RegistrationDescriptorInfo、AutowireableConstructorRegistrationDescriptor。调用量越多,生成的类型越多,二进制大小线性增长。 基类用子类作为模板参数 RegistrationDescriptorBase< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >。结合前面 4.1 说的,基类压根就没用上这两个模板参数,进一步加剧了生成类型的数量。 为了使得新增的调用信息对 contain_builder可见,contain_builder需要注册registrationDescriptorUpdated()通知,在回调里处理新的调用信息。代码架构非常复杂混乱。
问题如此严重,那要怎么优化?回头看作者的用心,大概或许应该是防止用户出错。例如映射的基类并不是基类、重复映射同一个基类、重复设置singleInstance,等等「可以在编译期发现的错误」。如果抛开这个大设定,其实所有配置信息都可以用一个 POD (Plain Old Data) 结构体来记录,最后再一次性生效。在 POD 结构体的基础上,我们再来看哪些是可以零成本在编译期完成的错误检查:
「映射的基类不是基类」:这个可以零成本在编译器实现,加一个static_assert<>即可。 「重复映射同一个基类」:这个其实可以在运行时规避/处理,我们用一个unordered_set来记录已映射的基类即可。退一万步说,把“映射同一个基类”做成幂等操作就行了,重复映射一万次都没关系。 「重复设置singleInstance」:同上,用一个 bool 标志位记录即可,以最后一次调用为准。
其他接口如named(), asSelf(), with()等等,也都可参考上面做法,分别在编译期/运行时解决,不再赘述。
4. 小技巧
4.1 多用组合,少用继承
通常来说,多用组合少用继承是设计模式上的好建议,它能提高灵活性、减低耦合度、增强代码复用、减少继承层次。但其实它还有瘦身意义上的好处。假设我们有一个模板类 GraphicObject,它有两个模板参数:Shape 表示形状类型,Color 表示颜色类型。
template<typename Shape, typename Color>
class GraphicObject {
public:
// ...
};
如果我们有很多不同的 Shape 和 Color 类型,那么 GraphicObject 的每种组合都会生成一个新的模板实例,这可能会导致生成的代码量非常大。
为了减少模板实例化的大小,我们可以将 Shape 和 Color 类型的处理逻辑分离出来,使它们成为 GraphicObject 的成员,而不是模板参数。这样,GraphicObject 就不再需要为每种 Shape 和 Color 类型的组合生成一个新的模板实例,从而减少了模板实例化的大小。
class ShapeBase {
public:
// ...
};
class ColorBase {
public:
// ...
};
class GraphicObject {
public:
GraphicObject(std::shared_ptr<ShapeBase> shape, std::shared_ptr<ColorBase> color)
: shape_(shape), color_(color) {}
// ...
private:
std::shared_ptr<ShapeBase> shape_;
std::shared_ptr<ColorBase> color_;
};
4.2 避免在模板函数中使用大型对象
模板函数中的对象会在每个模板实例中都生成一份,因此应该避免在模板函数中使用大型对象。如果必须使用大型对象,可以考虑使用指针或引用,或者将对象移动到函数外部。
4.3 善用手边工具,多测量监控代码膨胀问题
Linux 平台下(包括Android),可用 nm 打印出每个符号的大小并按大小排序:
nm --print-size --size-sort xxx_binary
在 macOS/iOS 平台下,可通过生成 LinkMapFile.txt 来进行分析。
❝需要注意的是,现代编译器能够对不同编译单元的相同模板函数进行去重,所以需要对最终链接产物(可执行文件 / 动态库)进行测量,不要对单个 .o .a 文件进行分析。
❞
优化效果
上述描述的策略目前正逐步应用到微信客户端内进行优化,目前的优化效果是:「将有24个 Service 的代码库从14M瘦身到11M,减少体积22%,效果非常明显。」
总结
总的来说,优化C++模板代码的关键是减少每个模板实例的大小,本文描述的优化策略可以帮助我们提高编译速度,减小生成的二进制文件大小,同时保持代码的可读性和可维护性,完整总结如下:
想了解更多「微信客户端技术及开发经验」,请关注「微信客户端技术团队公众号」。